Back to Blog
spring-bootperformancehibernatejpa

Transactional Anti-Patterns in Spring Boot That Kill Your App

@Transactional is one of the most misused annotations in Spring Boot. Private methods, HTTP calls inside transactions, missing readOnly — learn the anti-patterns that cause connection pool exhaustion and data inconsistency.

J

JOptimize Team

April 16, 2026· 5 min read

What is a ThreadLocal Memory Leak?

ThreadLocal lets you store data per-thread — a clean way to avoid passing context through every method call. But in Spring Boot, threads come from a pool. They're reused. And if you never clean up your ThreadLocal values, they accumulate across requests — causing memory leaks that grow silently until your server crashes.

How ThreadLocal Works in a Thread Pool

In a standalone application, threads are created and destroyed. ThreadLocal values die with the thread. No problem.

In Spring Boot, Tomcat reuses threads from a pool of typically 200 threads. If you store something in a ThreadLocal and never remove it:

Request 1 → Thread A → stores user context → request ends → Thread A returned to pool
Request 2 → Thread A (reused) → still has Request 1's user context → wrong data served

This causes both memory leaks and data leakage between requests.

The Classic Vulnerable Pattern

@Component public class UserContext { // ❌ Never cleaned up private static final ThreadLocal<User> currentUser = new ThreadLocal<>(); public static void set(User user) { currentUser.set(user); } public static User get() { return currentUser.get(); } // Missing: remove() method }
@Component public class AuthFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) { User user = resolveUser(request); UserContext.set(user); // ❌ Set but never removed chain.doFilter(request, response); // Thread returns to pool with user still stored } }

The Fix — Always Use try/finally

@Component public class AuthFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { try { User user = resolveUser(request); UserContext.set(user); chain.doFilter(request, response); } finally { UserContext.remove(); // ✅ Always cleaned up, even on exception } } }
@Component public class UserContext { private static final ThreadLocal<User> currentUser = new ThreadLocal<>(); public static void set(User user) { currentUser.set(user); } public static User get() { return currentUser.get(); } public static void remove() { currentUser.remove(); } // ✅ Always expose remove() }

The finally block guarantees cleanup even when an exception is thrown during request processing.

@Async Methods and ThreadLocal

@Service public class ReportService { // ❌ ThreadLocal values from the calling thread are NOT available here // @Async runs on a different thread from the pool @Async public void generateReport(Long userId) { User user = UserContext.get(); // Returns null — wrong thread // ... } }

@Async methods run on a separate thread pool. ThreadLocal values from the calling thread are not inherited. Pass data explicitly:

@Service public class ReportService { @Async public void generateReport(Long userId, String username) { // ✅ Pass explicitly // use userId and username directly } }

InheritableThreadLocal — Not a Silver Bullet

// InheritableThreadLocal copies values to child threads private static final InheritableThreadLocal<User> currentUser = new InheritableThreadLocal<>();

This works for threads created from the current thread — but not for thread pools. Thread pool threads are created once and reused, so the "inheritance" only happens at pool creation, not per-request. Still leaks.

The @PreDestroy Pattern for Executors

@Service public class ReportService { // ❌ Executor never shut down — threads (and their ThreadLocals) leak forever private final ExecutorService executor = Executors.newFixedThreadPool(10); public void runReport() { executor.submit(() -> generateReport()); } }
@Service public class ReportService { private final ExecutorService executor = Executors.newFixedThreadPool(10); public void runReport() { executor.submit(() -> generateReport()); } @PreDestroy // ✅ Shuts down executor when Spring context closes public void shutdown() { executor.shutdown(); } }

How JOptimize Detects ThreadLocal Leaks

joptimize analyze . # → [CRITICAL] ThreadLocal 'currentUser' set without remove() in same scope # Suggest wrapping in try/finally — AuthFilter.java:23 # # → [WARNING] ExecutorService created without @PreDestroy shutdown # Threads and their ThreadLocals will leak — ReportService.java:12

Quick Checklist

  • Always call remove() in a finally block after setting a ThreadLocal
  • Expose a remove() method on every ThreadLocal wrapper class
  • Never rely on ThreadLocal in @Async — pass data explicitly as parameters
  • Always @PreDestroy your ExecutorService beans
  • Use Spring's RequestContextHolder instead of custom ThreadLocal for request-scoped data — Spring manages cleanup automatically

Built-in Alternative — RequestContextHolder

Instead of rolling your own ThreadLocal, use Spring's built-in:

// Spring manages cleanup automatically for request-scoped data RequestAttributes attrs = RequestContextHolder.getRequestAttributes(); attrs.setAttribute("currentUser", user, RequestAttributes.SCOPE_REQUEST); // Retrieve it anywhere in the same request User user = (User) attrs.getAttribute("currentUser", RequestAttributes.SCOPE_REQUEST);

Spring's DispatcherServlet calls RequestContextHolder.resetRequestAttributes() at the end of every request — so cleanup is handled for you.

Detect ThreadLocal Leaks in Your Project

npm install -g @joptimize/cli joptimize auth jp_live_your_key joptimize analyze .

Or upload your ZIP at joptimize.io.

Want to go deeper?

Master Spring Boot, security, and Java performance with hands-on courses.

Detect issues in your project

JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.